import { useEffect, useMemo, useState } from 'react'; import { Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Switch, TextInput, View, } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; import { ResizeMode, Video } from 'expo-av'; import { useLocalSearchParams } from 'expo-router'; import { ThemedButton } from '@/components/themed-button'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; import { ZoomImageModal } from '@/components/zoom-image-modal'; import { Colors } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; import { useTranslation } from '@/localization/i18n'; import { dbPromise, initCoreTables } from '@/services/db'; type TaskRow = { id: number; name: string; description: string | null; }; type EntryRow = { id: number; status: string | null; notes: string | null; meta_json: string | null; completed_at: string | null; }; type MediaRow = { uri: string | null; }; export default function TaskDetailScreen() { const { t } = useTranslation(); const { id } = useLocalSearchParams<{ id?: string | string[] }>(); const taskId = Number(Array.isArray(id) ? id[0] : id); const theme = useColorScheme() ?? 'light'; const palette = Colors[theme]; const todayKey = useMemo(() => new Date().toISOString().slice(0, 10), []); const [task, setTask] = useState(null); const [entryId, setEntryId] = useState(null); const [status, setStatus] = useState(''); const [isDone, setIsDone] = useState(false); const [notes, setNotes] = useState(''); const [mediaUris, setMediaUris] = useState([]); const [activeUri, setActiveUri] = useState(null); const [zoomUri, setZoomUri] = useState(null); const [saving, setSaving] = useState(false); const [showSaved, setShowSaved] = useState(false); useEffect(() => { let isActive = true; async function loadTask() { try { await initCoreTables(); const db = await dbPromise; const taskRows = await db.getAllAsync( 'SELECT id, name, description FROM daily_tasks WHERE id = ? LIMIT 1;', taskId ); const entryRows = await db.getAllAsync( `SELECT id, status, notes, meta_json, completed_at FROM daily_task_entries WHERE task_id = ? AND substr(completed_at, 1, 10) = ? LIMIT 1;`, taskId, todayKey ); if (!isActive) return; setTask(taskRows[0] ?? null); const entry = entryRows[0]; if (entry) { setEntryId(entry.id); setNotes(entry.notes ?? ''); const entryStatus = entry.status ?? ''; setStatus(entryStatus); setIsDone(entryStatus === 'done'); const mediaRows = await db.getAllAsync( 'SELECT uri FROM task_entry_media WHERE entry_id = ? ORDER BY created_at ASC;', entry.id ); const media = uniqueMediaUris(mediaRows.map((row) => row.uri).filter(Boolean) as string[]); const fallback = parseTaskMeta(entry.meta_json)?.photoUri; const merged = uniqueMediaUris([ ...media, ...(normalizeMediaUri(fallback) ? [normalizeMediaUri(fallback) as string] : []), ]); setMediaUris(merged); setActiveUri(merged[0] ?? null); } } catch (error) { if (isActive) setStatus(`Error: ${String(error)}`); } } loadTask(); return () => { isActive = false; }; }, [taskId, todayKey]); const inputStyle = [ styles.input, { borderColor: palette.border, backgroundColor: palette.input, color: palette.text, }, ]; async function handleSave(nextStatus?: string) { if (!task) return; try { setSaving(true); const db = await dbPromise; const now = new Date().toISOString(); const statusValue = nextStatus ?? (isDone ? 'done' : 'open'); let currentEntryId = entryId; if (!currentEntryId) { const result = await db.runAsync( 'INSERT INTO daily_task_entries (task_id, field_id, notes, status, completed_at, created_at, meta_json) VALUES (?, NULL, ?, ?, ?, ?, ?);', task.id, notes.trim() || null, statusValue, now, now, serializeTaskMeta({ photoUri: mediaUris[0] }) ); currentEntryId = Number(result.lastInsertRowId); setEntryId(currentEntryId); } else { await db.runAsync( 'UPDATE daily_task_entries SET notes = ?, status = ?, completed_at = ?, meta_json = ? WHERE id = ?;', notes.trim() || null, statusValue, now, serializeTaskMeta({ photoUri: mediaUris[0] }), currentEntryId ); } if (currentEntryId) { await db.runAsync('DELETE FROM task_entry_media WHERE entry_id = ?;', currentEntryId); for (const uri of uniqueMediaUris(mediaUris)) { await db.runAsync( 'INSERT INTO task_entry_media (entry_id, uri, media_type, created_at) VALUES (?, ?, ?, ?);', currentEntryId, uri, isVideoUri(uri) ? 'video' : 'image', now ); } } setStatus(statusValue === 'done' ? t('tasks.done') : t('tasks.open')); setShowSaved(true); setTimeout(() => { setShowSaved(false); setStatus(''); }, 1800); } catch (error) { setStatus(`Error: ${String(error)}`); } finally { setSaving(false); } } return ( {task?.name ?? t('tasks.title')} {task?.description ? {task.description} : null} {t('tasks.done')} setIsDone(value)} trackColor={{ false: palette.border, true: palette.tint }} thumbColor={palette.card} /> {t('tasks.notePlaceholder')} {t('tasks.addMedia')} {normalizeMediaUri(activeUri) ? ( isVideoUri(normalizeMediaUri(activeUri) as string) ? ( setZoomUri(null)} /> ); } async function handlePickMedia(onAdd: (uris: string[]) => void) { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: getMediaTypes(), quality: 1, allowsMultipleSelection: true, selectionLimit: 0, }); if (result.canceled) return; const uris = (result.assets ?? []).map((asset) => asset.uri).filter(Boolean) as string[]; if (uris.length === 0) return; onAdd(uris); } async function handleTakeMedia(onAdd: (uri: string | null) => void) { const permission = await ImagePicker.requestCameraPermissionsAsync(); if (!permission.granted) return; const result = await ImagePicker.launchCameraAsync({ mediaTypes: getMediaTypes(), quality: 1, }); if (result.canceled) return; const asset = result.assets[0]; onAdd(asset.uri); } function getMediaTypes() { const mediaType = (ImagePicker as { MediaType?: { Image?: unknown; Images?: unknown; Video?: unknown; Videos?: unknown }; }).MediaType; const imageType = mediaType?.Image ?? mediaType?.Images; const videoType = mediaType?.Video ?? mediaType?.Videos; if (imageType && videoType) { return [imageType, videoType]; } return imageType ?? videoType ?? ['images', 'videos']; } function isVideoUri(uri: string) { return /\.(mp4|mov|m4v|webm|avi|mkv)(\?.*)?$/i.test(uri); } function normalizeMediaUri(uri?: string | null) { if (typeof uri !== 'string') return null; const trimmed = uri.trim(); return trimmed ? trimmed : null; } function uniqueMediaUris(uris: string[]) { const seen = new Set(); const result: string[] = []; for (const uri of uris) { if (!uri || seen.has(uri)) continue; seen.add(uri); result.push(uri); } return result; } function parseTaskMeta(raw: string | null) { if (!raw) return {} as { photoUri?: string }; try { return JSON.parse(raw) as { photoUri?: string }; } catch { return {} as { photoUri?: string }; } } function serializeTaskMeta(meta: { photoUri?: string }) { if (!meta.photoUri) return null; return JSON.stringify(meta); } const styles = StyleSheet.create({ container: { flex: 1, }, keyboardAvoid: { flex: 1, }, content: { padding: 16, gap: 10, paddingBottom: 40, }, input: { borderRadius: 10, borderWidth: 1, paddingHorizontal: 12, paddingVertical: 10, fontSize: 15, }, mediaPreview: { width: '100%', height: 220, borderRadius: 12, backgroundColor: '#1C1C1C', }, photoRow: { flexDirection: 'row', gap: 8, }, actions: { marginTop: 12, flexDirection: 'row', justifyContent: 'flex-end', alignItems: 'center', gap: 10, }, photoPlaceholder: { opacity: 0.6, }, mediaStrip: { marginTop: 6, }, mediaChip: { width: 72, height: 72, borderRadius: 10, marginRight: 8, overflow: 'hidden', backgroundColor: '#E6E1D4', alignItems: 'center', justifyContent: 'center', }, mediaThumb: { width: '100%', height: '100%', }, videoThumb: { width: '100%', height: '100%', backgroundColor: '#1C1C1C', alignItems: 'center', justifyContent: 'center', }, videoThumbText: { color: '#FFFFFF', fontSize: 18, fontWeight: '700', }, mediaRemove: { position: 'absolute', top: 4, right: 4, width: 18, height: 18, borderRadius: 9, backgroundColor: 'rgba(0,0,0,0.6)', alignItems: 'center', justifyContent: 'center', }, mediaRemoveText: { color: '#FFFFFF', fontSize: 12, lineHeight: 14, fontWeight: '700', }, updateGroup: { flexDirection: 'row', alignItems: 'center', gap: 8, }, inlineToastText: { fontWeight: '700', fontSize: 12, }, statusRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, }, });